import React, { useState, useEffect, useMemo, useRef } from 'react';
import {
Star,
RotateCw,
Plus,
ArrowLeft,
Users,
Shield,
Trash2,
Edit3,
Copy,
Camera,
Image as ImageIcon,
Lock,
Eye,
EyeOff,
Calculator,
X,
Globe,
User,
LogOut,
Coins,
Check,
PiggyBank,
AlertTriangle,
Sparkles,
Search,
Calendar,
Activity,
Wallet,
PieChart,
Unlock,
Dice5,
ChevronRight
} from 'lucide-react';
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, signInAnonymously, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, setDoc, getDoc, collection, onSnapshot, updateDoc, deleteDoc } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
const localFirebaseConfig = {
apiKey: "AIzaSyCQiCaR4vSSSCm4eDnAmqPHEVo0SOu8gL8",
authDomain: "wuru-bills.firebaseapp.com",
projectId: "wuru-bills",
storageBucket: "wuru-bills.firebasestorage.app",
messagingSenderId: "484804881566",
appId: "1:484804881566:web:7fa2c4ad521a2ec596df8f",
measurementId: "G-PYCZ08EQRN"
};
const appId = 'wuru-bills-app';
const firebaseApp = initializeApp(localFirebaseConfig);
const auth = getAuth(firebaseApp);
const db = getFirestore(firebaseApp);
const DRIVE_GAS_URL = 'https://script.google.com/macros/s/AKfycbwPmYPAKsOsPW4Qhcx8D9T_qILr1glY-DzsK9fbz-q8gBkBNALW9tvrWNmOoBefLg8T-g/exec';
const ADMIN_USERNAMES = ['admin', 'maywuru'];
const AVATAR_LIST = Array.from({ length: 40 }, (_, i) => `F${i + 1}.png`);
export default function App() {
const [currentUser, setCurrentUser] = useState(null);
const [currentScreen, setCurrentScreen] = useState('login'); // 'login', 'dashboard', 'bill-detail', 'members'
// Real-time caches from Firestore
const [cacheUsers, setCacheUsers] = useState([]);
const [cacheBills, setCacheBills] = useState([]);
const [cacheTransactions, setCacheTransactions] = useState([]);
const [cachePermissions, setCachePermissions] = useState([]);
// App-wide loading, toast and custom confirm dialog states
const [isLoading, setIsLoading] = useState(false);
const [loadingText, setLoadingText] = useState('กำลังโหลด...');
const [toast, setToast] = useState({ message: '', type: 'success', visible: false });
const [confirmDialog, setConfirmDialog] = useState({ visible: false, title: '', message: '', onConfirm: null });
// Search & Filter controls
const [searchBillsQuery, setSearchBillsQuery] = useState('');
const [sortBillsBy, setSortBillsBy] = useState('newest');
const [filterOldTrips, setFilterOldTrips] = useState(false);
const [searchMembersQuery, setSearchMembersQuery] = useState('');
// Navigation / Details states
const [currentActiveBillId, setCurrentActiveBillId] = useState(null);
const [currentActiveBillRole, setCurrentActiveBillRole] = useState('Viewer');
const [billTab, setBillTab] = useState('tx'); // 'tx' or 'summary'
// Login input states
const [usernameInput, setUsernameInput] = useState('');
const [passwordInput, setPasswordInput] = useState('');
const [showLoginPassword, setShowLoginPassword] = useState(false);
const [isCalcOpen, setIsCalcOpen] = useState(false);
const [calcExpression, setCalcExpression] = useState('');
const [calcCurrentInput, setCalcCurrentInput] = useState('0');
const [calcJustEvaluated, setCalcJustEvaluated] = useState(false);
// Modal control states
const [isBillModalOpen, setIsBillModalOpen] = useState(false);
const [isTxModalOpen, setIsTxModalOpen] = useState(false);
const [isMemberModalOpen, setIsMemberModalOpen] = useState(false);
const [isPermissionModalOpen, setIsPermissionModalOpen] = useState(false);
const [isUserPermissionModalOpen, setIsUserPermissionModalOpen] = useState(false);
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
// Form states
const [billForm, setBillForm] = useState({
id: '',
name: '',
start: '',
end: '',
status: 'เปิดอยู่',
currency1: 'THB',
exchangeRate1: 1,
currency2: 'NONE',
exchangeRate2: 0,
currency3: 'NONE',
exchangeRate3: 0,
selectedMembers: [],
searchQuery: ''
});
const [txForm, setTxForm] = useState({
id: '',
description: '',
spentCurrency: 'THB',
foreignAmount: '',
exchangeRate: 1,
totalAmount: '',
date: '',
paidBy: '',
splitType: 'หารเท่า',
splitTarget: '',
splitRatios: {}, // { memberName: ratio }
selectedFile: null,
fileName: '',
existingImg: '',
existingFileId: ''
});
const [memberForm, setMemberForm] = useState({
originalName: '',
displayName: '',
avatar: 'F1.png',
allowLogin: 'No',
username: '',
password: ''
});
const [selectedUserPermissionName, setSelectedUserPermissionName] = useState('');
const [profileForm, setProfileForm] = useState({
avatar: 'F1.png',
newPassword: '',
showPassword: false
});
useEffect(() => {
setIsLoading(true);
setLoadingText('กำลังเชื่อมต่อคลาวด์ความเร็วสูง...');
// Auto-login from localStorage if exists
const savedUser = localStorage.getItem('splitbill_user');
if (savedUser) {
setCurrentUser(JSON.parse(savedUser));
}
let authUnsubscribe;
let usersUnsubscribe;
let permissionsUnsubscribe;
let billsUnsubscribe;
let transactionsUnsubscribe;
const setupSync = () => {
const getColRef = (colName) => collection(db, 'artifacts', appId, 'public', 'data', colName);
usersUnsubscribe = onSnapshot(getColRef('Users'), (snap) => {
const list = snap.docs.map(d => d.data());
setCacheUsers(list);
});
permissionsUnsubscribe = onSnapshot(getColRef('Permissions'), (snap) => {
setCachePermissions(snap.docs.map(d => d.data()));
});
billsUnsubscribe = onSnapshot(getColRef('Bills'), (snap) => {
const list = snap.docs.map(d => d.data());
setCacheBills(list);
});
transactionsUnsubscribe = onSnapshot(getColRef('Transactions'), (snap) => {
setCacheTransactions(snap.docs.map(d => d.data()));
});
setIsLoading(false);
};
authUnsubscribe = onAuthStateChanged(auth, async (user) => {
if (user) {
setupSync();
// Handle direct share link if URL query parameters exist
const urlParams = new URLSearchParams(window.location.search);
const publicBillId = urlParams.get('billId');
const publicKey = urlParams.get('key');
if (publicBillId && publicKey) {
const guestUser = { username: 'guest', displayName: 'Guest', role: 'Viewer' };
setCurrentUser(guestUser);
setCurrentActiveBillId(publicBillId);
setCurrentActiveBillRole('Viewer');
setCurrentScreen('bill-detail');
} else if (localStorage.getItem('splitbill_user')) {
setCurrentScreen('dashboard');
} else {
setCurrentScreen('login');
}
} else {
signInAnonymously(auth).catch(err => {
showToast('การยืนยันสิทธิ์ล้มเหลว: ' + err.message, 'error');
});
}
});
return () => {
if (authUnsubscribe) authUnsubscribe();
if (usersUnsubscribe) usersUnsubscribe();
if (permissionsUnsubscribe) permissionsUnsubscribe();
if (billsUnsubscribe) billsUnsubscribe();
if (transactionsUnsubscribe) transactionsUnsubscribe();
};
}, []);
const dbCurrentUserObj = useMemo(() => {
if (!currentUser) return null;
return cacheUsers.find(u =>
(u.Username && u.Username.toLowerCase() === currentUser.username.toLowerCase()) ||
(u.Display_Name === currentUser.displayName)
);
}, [currentUser, cacheUsers]);
const userAvatarUrl = dbCurrentUserObj?.Avatar || 'F1.png';
const showToast = (message, type = 'success') => {
setToast({ message, type, visible: true });
setTimeout(() => {
setToast(prev => ({ ...prev, visible: false }));
}, 3000);
};
const triggerConfirm = (title, message, onConfirm) => {
setConfirmDialog({ visible: true, title, message, onConfirm });
};
const handleLoginSubmit = async (e) => {
e.preventDefault();
if (!usernameInput || !passwordInput) {
showToast('กรุณากรอกข้อมูลให้ครบถ้วนนะค้า', 'error');
return;
}
setIsLoading(true);
setLoadingText('กำลังตรวจสอบข้อมูลผู้ใช้...');
try {
const userObj = cacheUsers.find(x =>
(x.Username && x.Username.toLowerCase() === usernameInput.trim().toLowerCase()) ||
(x.Display_Name && x.Display_Name.toLowerCase() === usernameInput.trim().toLowerCase())
);
if (!userObj) throw new Error('ไม่พบชื่อผู้ใช้งานนี้ในระบบ');
if (userObj.Allow_Login !== 'Yes') throw new Error('บัญชีนี้ยังไม่ได้รับอนุญาตให้ล็อกอินน้า');
if (userObj.Status === 'Locked') throw new Error('บัญชีถูกล็อกเนื่องจากรหัสผ่านผิดเกิน 5 ครั้ง');
const userDocRef = doc(db, 'artifacts', appId, 'public', 'data', 'Users', userObj.Display_Name);
if (userObj.Password === passwordInput) {
await updateDoc(userDocRef, { Failed_Attempts: 0 });
const isAdmin = (userObj.Username && ADMIN_USERNAMES.includes(userObj.Username.toLowerCase())) || userObj.Display_Name === 'เม';
const loggedInUser = { username: userObj.Username, displayName: userObj.Display_Name, isAdmin: isAdmin };
setCurrentUser(loggedInUser);
localStorage.setItem('splitbill_user', JSON.stringify(loggedInUser));
setUsernameInput('');
setPasswordInput('');
setCurrentScreen('dashboard');
showToast(`ยินดีต้อนรับ ${userObj.Display_Name}! 🐹`, 'success');
} else {
const fails = (parseInt(userObj.Failed_Attempts) || 0) + 1;
if (fails >= 5) {
await updateDoc(userDocRef, { Failed_Attempts: fails, Status: 'Locked' });
throw new Error('บัญชีถูกล็อกแล้ว เนื่องจากใส่รหัสผิดครบ 5 ครั้งจ้า');
} else {
await updateDoc(userDocRef, { Failed_Attempts: fails });
throw new Error(`รหัสผ่านไม่ถูกต้อง! (ผิดไปแล้ว ${fails}/5 ครั้ง)`);
}
}
} catch (e) {
showToast(e.message, 'error');
} finally {
setIsLoading(false);
}
};
const handleLogout = () => {
triggerConfirm('ออกจากระบบ', 'คุณต้องการจะออกจากระบบบิลใช่หรือไม่จ๊ะ? 🥺', () => {
localStorage.removeItem('splitbill_user');
setCurrentUser(null);
setCurrentScreen('login');
showToast('ออกจากระบบสำเร็จแล้วค่ะ');
});
};
const allowedBills = useMemo(() => {
if (!currentUser) return [];
if (currentUser.isAdmin || currentUser.displayName === 'เม' || currentUser.username === 'maywuru') {
return cacheBills;
}
return cacheBills.filter(bill => {
const perm = cachePermissions.find(p => p.Bill_ID === bill.Bill_ID && p.Username === currentUser.username);
if (perm && perm.Can_See === 'No') return false;
if (perm && perm.Can_See === 'Yes') return true;
const members = bill.Members ? bill.Members.split(',').map(m => m.trim()) : [];
return members.includes(currentUser.displayName);
});
}, [currentUser, cacheBills, cachePermissions]);
const sortedAndFilteredBills = useMemo(() => {
let result = [...allowedBills];
// Filter old cleared trips for admins
if (filterOldTrips && currentUser?.isAdmin) {
const today = new Date();
result = result.filter(b => {
const isCleared = b.Status === 'เคลียร์แล้ว' || b.Status === 'สำเร็จแล้ว';
if (!isCleared) return false;
if (!b.Start_Date) return false;
const tripDate = new Date(b.Start_Date);
const diffTime = Math.abs(today - tripDate);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays > 150; // 5 months approx
});
}
// Search query filter
if (searchBillsQuery.trim()) {
result = result.filter(b => b.Bill_Name && b.Bill_Name.toLowerCase().includes(searchBillsQuery.toLowerCase()));
}
// Sorting
result.sort((a, b) => {
if (sortBillsBy === 'newest') {
return new Date(b.Start_Date || 0) - new Date(a.Start_Date || 0);
} else if (sortBillsBy === 'oldest') {
return new Date(a.Start_Date || 0) - new Date(b.Start_Date || 0);
} else if (sortBillsBy === 'name_asc') {
return (a.Bill_Name || '').localeCompare(b.Bill_Name || '', 'th');
} else if (sortBillsBy === 'name_desc') {
return (b.Bill_Name || '').localeCompare(a.Bill_Name || '', 'th');
}
return 0;
});
return result;
}, [allowedBills, filterOldTrips, searchBillsQuery, sortBillsBy, currentUser]);
const currentActiveBillObj = useMemo(() => {
return cacheBills.find(b => b.Bill_ID === currentActiveBillId) || null;
}, [currentActiveBillId, cacheBills]);
const handleCurrencyFieldChange = async (num, currencyCode) => {
setBillForm(prev => {
const next = { ...prev };
next[`currency${num}`] = currencyCode;
if (currencyCode === 'THB') {
next[`exchangeRate${num}`] = 1;
} else if (currencyCode === 'NONE') {
next[`exchangeRate${num}`] = 0;
}
return next;
});
if (currencyCode !== 'THB' && currencyCode !== 'NONE') {
await fetchRateForBillForm(num, currencyCode);
}
};
const fetchRateForBillForm = async (num, code) => {
setIsLoading(true);
setLoadingText('กำลังดึงเรทค่าเงินกลางเรียลไทม์...');
try {
const res = await fetch(`https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/${code.toLowerCase()}.json`);
if (!res.ok) throw new Error('API Error');
const data = await res.json();
const rate = data[code.toLowerCase()]['thb'];
setBillForm(prev => ({
...prev,
[`exchangeRate${num}`]: parseFloat(rate).toFixed(5)
}));
showToast('ดึงเรทอัตราแลกเปลี่ยนปัจจุบันสำเร็จ ⚡');
} catch (e) {
showToast('ไม่สามารถเชื่อมต่อดึงเรทเงินได้ กรุณากรอกเองนะค้า', 'error');
} finally {
setIsLoading(false);
}
};
const triggerOpenBillModal = (existingId = '') => {
if (existingId) {
const bill = cacheBills.find(b => b.Bill_ID === existingId);
if (bill) {
setBillForm({
id: bill.Bill_ID,
name: bill.Bill_Name || '',
start: bill.Start_Date || '',
end: bill.End_Date || '',
status: bill.Status || 'เปิดอยู่',
currency1: bill.Currency_1 || bill.Currency || 'THB',
exchangeRate1: bill.Exchange_Rate_1 || bill.Exchange_Rate || 1,
currency2: bill.Currency_2 || 'NONE',
exchangeRate2: bill.Exchange_Rate_2 || 0,
currency3: bill.Currency_3 || 'NONE',
exchangeRate3: bill.Exchange_Rate_3 || 0,
selectedMembers: bill.Members ? bill.Members.split(',').map(m => m.trim()) : [],
searchQuery: ''
});
}
} else {
setBillForm({
id: '',
name: '',
start: new Date().toISOString().split('T')[0],
end: '',
status: 'เปิดอยู่',
currency1: 'THB',
exchangeRate1: 1,
currency2: 'NONE',
exchangeRate2: 0,
currency3: 'NONE',
exchangeRate3: 0,
selectedMembers: [],
searchQuery: ''
});
}
setIsBillModalOpen(true);
};
const handleSaveBill = async (e) => {
e.preventDefault();
if (!billForm.name || !billForm.start) {
showToast('กรุณากรอกชื่อทริปและวันที่เริ่มนะค้า', 'error');
return;
}
if (billForm.selectedMembers.length === 0) {
showToast('กรุณาเลือกเพื่อนในทริปอย่างน้อย 1 คนน้า', 'error');
return;
}
setIsLoading(true);
setLoadingText('กำลังบันทึกข้อมูลทริปไปยังคลาวด์...');
try {
const membersText = billForm.selectedMembers.join(', ');
const bData = {
Bill_ID: billForm.id || 'B' + Date.now().toString().slice(-6),
Bill_Name: billForm.name,
Start_Date: billForm.start,
End_Date: billForm.end || '',
Status: billForm.status,
Members: membersText,
Currency_1: billForm.currency1,
Exchange_Rate_1: parseFloat(billForm.exchangeRate1) || 1,
Currency_2: billForm.currency2,
Exchange_Rate_2: parseFloat(billForm.exchangeRate2) || 0,
Currency_3: billForm.currency3,
Exchange_Rate_3: parseFloat(billForm.exchangeRate3) || 0,
Currency: billForm.currency1, // Backwards compatibility
Exchange_Rate: parseFloat(billForm.exchangeRate1) || 1,
};
if (!billForm.id) {
bData.Share_Key = '';
}
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Bills', bData.Bill_ID), bData, { merge: true });
showToast(billForm.id ? 'แก้ไขข้อมูลทริปเรียบร้อย 💖' : 'สร้างทริปสำเร็จและเปิดเรียลไทม์แล้ว 💖');
setIsBillModalOpen(false);
} catch (e) {
showToast('เกิดข้อผิดพลาด: ' + e.message, 'error');
} finally {
setIsLoading(false);
}
};
const activeBillTransactions = useMemo(() => {
return cacheTransactions.filter(t => t.Bill_ID === currentActiveBillId);
}, [cacheTransactions, currentActiveBillId]);
const activeBillMembersList = useMemo(() => {
if (!currentActiveBillObj) return [];
return currentActiveBillObj.Members ? currentActiveBillObj.Members.split(',').map(m => m.trim()).filter(m => m) : [];
}, [currentActiveBillObj]);
const debtSettlementData = useMemo(() => {
if (!currentActiveBillObj || activeBillMembersList.length === 0) return { totalTrip: 0, avgPerPerson: 0, settlements: [], memberStats: [] };
let paidTotal = {};
let shareTotal = {};
let netBalance = {};
activeBillMembersList.forEach(m => {
paidTotal[m] = 0;
shareTotal[m] = 0;
netBalance[m] = 0;
});
activeBillTransactions.forEach(tx => {
const amt = parseFloat(tx.Total_Amount) || 0;
const payer = tx.Paid_By;
if (paidTotal[payer] !== undefined) {
paidTotal[payer] += amt;
}
if (tx.Split_Type === 'หารเท่า') {
const splitAmt = amt / activeBillMembersList.length;
activeBillMembersList.forEach(m => {
if (shareTotal[m] !== undefined) shareTotal[m] += splitAmt;
});
} else if (tx.Split_Type === 'จ่ายเต็มจำนวน') {
const target = tx.Split_Detail?.trim();
if (shareTotal[target] !== undefined) {
shareTotal[target] += amt;
}
} else if (tx.Split_Type === 'กำหนดสัดส่วน') {
const parts = tx.Split_Detail ? tx.Split_Detail.split(',') : [];
let totalParts = 0;
let parsedParts = {};
parts.forEach(p => {
const [name, part] = p.split(':');
if (name && part) {
const n = name.trim();
const pt = parseFloat(part);
parsedParts[n] = pt;
totalParts += pt;
}
});
if (totalParts > 0) {
for (let name in parsedParts) {
if (shareTotal[name] !== undefined) {
shareTotal[name] += (parsedParts[name] / totalParts) * amt;
}
}
}
}
});
let debtors = [];
let creditors = [];
activeBillMembersList.forEach(m => {
netBalance[m] = paidTotal[m] - shareTotal[m];
const val = Math.round(netBalance[m] * 100) / 100;
if (val > 0.05) {
creditors.push({ name: m, amount: val });
} else if (val < -0.05) {
debtors.push({ name: m, amount: Math.abs(val) });
}
});
debtors.sort((a, b) => b.amount - a.amount);
creditors.sort((a, b) => b.amount - a.amount);
let settlements = [];
let i = 0, j = 0;
const debtTemp = debtors.map(d => ({ ...d }));
const credTemp = creditors.map(c => ({ ...c }));
while (i < debtTemp.length && j < credTemp.length) {
let d = debtTemp[i];
let c = credTemp[j];
let amountToSettle = Math.min(d.amount, c.amount);
if (amountToSettle > 0.01) {
settlements.push({ from: d.name, to: c.name, amount: amountToSettle });
d.amount -= amountToSettle;
c.amount -= amountToSettle;
}
if (d.amount < 0.05) i++;
if (c.amount < 0.05) j++;
}
const totalTrip = activeBillTransactions.reduce((sum, t) => sum + (parseFloat(t.Total_Amount) || 0), 0);
const avgPerPerson = totalTrip / activeBillMembersList.length;
const memberStats = activeBillMembersList.map(m => ({
name: m,
paid: paidTotal[m],
share: shareTotal[m],
net: netBalance[m]
}));
return { totalTrip, avgPerPerson, settlements, memberStats };
}, [activeBillTransactions, activeBillMembersList, currentActiveBillObj]);
const handleTxCurrencyChange = (currencyCode) => {
let rate = 1;
if (currencyCode !== 'THB') {
const activeTrip = currentActiveBillObj;
if (activeTrip) {
if (activeTrip.Currency_1 === currencyCode) rate = activeTrip.Exchange_Rate_1 || 1;
else if (activeTrip.Currency_2 === currencyCode) rate = activeTrip.Exchange_Rate_2 || 1;
else if (activeTrip.Currency_3 === currencyCode) rate = activeTrip.Exchange_Rate_3 || 1;
}
}
setTxForm(prev => {
const updated = { ...prev, spentCurrency: currencyCode, exchangeRate: rate };
if (currencyCode === 'THB') {
updated.foreignAmount = '';
updated.exchangeRate = 1;
} else {
if (updated.foreignAmount) {
updated.totalAmount = (parseFloat(updated.foreignAmount) * rate).toFixed(2);
}
}
return updated;
});
};
const handleTxForeignAmountChange = (val) => {
setTxForm(prev => {
const updated = { ...prev, foreignAmount: val };
if (prev.spentCurrency !== 'THB' && val !== '') {
updated.totalAmount = (parseFloat(val) * parseFloat(prev.exchangeRate)).toFixed(2);
}
return updated;
});
};
const handleTxExchangeRateChange = (rateVal) => {
setTxForm(prev => {
const updated = { ...prev, exchangeRate: rateVal };
if (prev.spentCurrency !== 'THB' && prev.foreignAmount !== '') {
updated.totalAmount = (parseFloat(prev.foreignAmount) * parseFloat(rateVal)).toFixed(2);
}
return updated;
});
};
const triggerOpenTxModal = (existingTxId = '') => {
if (!currentActiveBillObj) return;
const members = activeBillMembersList;
const defaultPayer = currentUser && members.includes(currentUser.displayName) ? currentUser.displayName : members[0] || '';
// Determine available currencies for this trip
let currencies = [];
const activeTrip = currentActiveBillObj;
if (activeTrip.Currency_1) currencies.push({ code: activeTrip.Currency_1, rate: activeTrip.Exchange_Rate_1 });
else if (activeTrip.Currency) currencies.push({ code: activeTrip.Currency, rate: activeTrip.Exchange_Rate });
else currencies.push({ code: 'THB', rate: 1 });
if (activeTrip.Currency_2 && activeTrip.Currency_2 !== 'NONE') {
currencies.push({ code: activeTrip.Currency_2, rate: activeTrip.Exchange_Rate_2 });
}
if (activeTrip.Currency_3 && activeTrip.Currency_3 !== 'NONE') {
currencies.push({ code: activeTrip.Currency_3, rate: activeTrip.Exchange_Rate_3 });
}
if (!currencies.some(c => c.code === 'THB')) {
currencies.unshift({ code: 'THB', rate: 1 });
}
if (existingTxId) {
const tx = cacheTransactions.find(t => t.Transaction_ID === existingTxId);
if (tx) {
let initialRatios = {};
if (tx.Split_Type === 'กำหนดสัดส่วน') {
const parts = tx.Split_Detail ? tx.Split_Detail.split(',') : [];
parts.forEach(p => {
const [name, val] = p.split(':');
if (name && val) initialRatios[name.trim()] = val.trim();
});
}
setTxForm({
id: tx.Transaction_ID,
description: tx.Description || '',
spentCurrency: tx.Spent_Currency || 'THB',
foreignAmount: tx.Foreign_Amount || '',
exchangeRate: tx.Exchange_Rate || 1,
totalAmount: tx.Total_Amount || '',
date: tx.Transaction_Date || '',
paidBy: tx.Paid_By || defaultPayer,
splitType: tx.Split_Type || 'หารเท่า',
splitTarget: tx.Split_Type === 'จ่ายเต็มจำนวน' ? tx.Split_Detail : (members[0] || ''),
splitRatios: initialRatios,
selectedFile: null,
fileName: '',
existingImg: tx.Image_URL || '',
existingFileId: tx.File_ID || ''
});
}
} else {
setTxForm({
id: '',
description: '',
spentCurrency: 'THB',
foreignAmount: '',
exchangeRate: 1,
totalAmount: '',
date: new Date().toISOString().split('T')[0],
paidBy: defaultPayer,
splitType: 'หารเท่า',
splitTarget: members[0] || '',
splitRatios: members.reduce((acc, name) => ({ ...acc, [name]: '1' }), {}),
selectedFile: null,
fileName: '',
existingImg: '',
existingFileId: ''
});
}
setIsTxModalOpen(true);
};
const handleTxFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
setTxForm(prev => ({ ...prev, selectedFile: file, fileName: file.name }));
}
};
const handleSaveTransaction = async (e) => {
e.preventDefault();
if (!txForm.description || !txForm.totalAmount) {
showToast('กรุณากรอกหัวข้อรายการและจำนวนเงินบาทนะค้า', 'error');
return;
}
setIsLoading(true);
setLoadingText('กำลังอัปโหลดข้อมูลและบันทึกบิลย่อย...');
try {
let finalImgUrl = txForm.existingImg;
let finalFileId = txForm.existingFileId;
if (txForm.selectedFile) {
const base64 = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(txForm.selectedFile);
});
if (txForm.id && txForm.existingFileId) {
await fetch(DRIVE_GAS_URL, {
method: 'POST',
body: JSON.stringify({ action: 'deleteImage', fileId: txForm.existingFileId })
}).catch(() => {});
}
const tempIdForFile = txForm.id || 'T' + Date.now().toString().slice(-6);
const res = await fetch(DRIVE_GAS_URL, {
method: 'POST',
body: JSON.stringify({ action: 'uploadImage', base64, filename: `receipt_${tempIdForFile}.jpg` })
});
const uploadRes = await res.json();
if (!uploadRes.success) throw new Error(uploadRes.error);
finalImgUrl = uploadRes.data.url;
finalFileId = uploadRes.data.fileId;
}
let detail = 'ทุกคน';
if (txForm.splitType === 'จ่ายเต็มจำนวน') {
detail = txForm.splitTarget;
} else if (txForm.splitType === 'กำหนดสัดส่วน') {
const parts = [];
for (let m in txForm.splitRatios) {
const val = parseFloat(txForm.splitRatios[m]) || 0;
if (val > 0) parts.push(`${m}:${val}`);
}
detail = parts.join(', ');
if (!detail) throw new Error('ต้องมีเพื่อนร่วมแชร์ค่าใช้จ่ายอย่างน้อยหนึ่งคนนะค้า');
}
const txId = txForm.id || 'T' + Date.now().toString().slice(-6);
const transactionData = {
Transaction_ID: txId,
Bill_ID: currentActiveBillId,
Transaction_Date: txForm.date,
Description: txForm.description,
Total_Amount: parseFloat(txForm.totalAmount) || 0,
Foreign_Amount: txForm.spentCurrency !== 'THB' ? parseFloat(txForm.foreignAmount) || 0 : 0,
Exchange_Rate: txForm.spentCurrency !== 'THB' ? parseFloat(txForm.exchangeRate) || 1 : 1,
Spent_Currency: txForm.spentCurrency,
Paid_By: txForm.paidBy,
Split_Type: txForm.splitType,
Split_Detail: detail,
Image_URL: finalImgUrl,
File_ID: finalFileId
};
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Transactions', txId), transactionData, { merge: true });
showToast(txForm.id ? 'แก้ไขรายการจ่ายสำเร็จแล้วค่ะ ✏️' : 'บันทึกรายการจ่ายเรียบร้อย! ⚡');
setIsTxModalOpen(false);
} catch (e) {
showToast(e.message, 'error');
} finally {
setIsLoading(false);
}
};
const handleDeleteTransaction = (txId, fileId) => {
triggerConfirm('ลบรายการใช้จ่าย', 'ยืนยันที่จะลบรายการนี้นะค้า? (สลิปเดิมในระบบ Google Drive จะถูกย้ายไปถังขยะอัตโนมัติด้วย)', async () => {
setIsLoading(true);
setLoadingText('กำลังนำรายการออกจากระบบ...');
try {
if (fileId && fileId !== 'null' && fileId !== 'undefined') {
await fetch(DRIVE_GAS_URL, {
method: 'POST',
body: JSON.stringify({ action: 'deleteImage', fileId })
}).catch(() => {});
}
await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Transactions', txId));
showToast('ลบรายการใช้จ่ายเรียบร้อย');
} catch (e) {
showToast(e.message, 'error');
} finally {
setIsLoading(false);
}
});
};
const handleDeleteCurrentBill = () => {
if (!currentActiveBillId) return;
triggerConfirm('🚨 ลบข้อมูลทริปถาวร', 'คุณกำลังจะลบทริปนี้ทิ้งอย่างถาวร (รวมถึงรายการจ่ายและสลิปทั้งหมด) ยืนยันหรือไม่?', async () => {
setIsLoading(true);
setLoadingText('กำลังลบข้อมูลทริปถาวร...');
try {
const txsToDelete = cacheTransactions.filter(t => t.Bill_ID === currentActiveBillId);
for (let tx of txsToDelete) {
if (tx.File_ID) {
await fetch(DRIVE_GAS_URL, {
method: 'POST',
body: JSON.stringify({ action: 'deleteImage', fileId: tx.File_ID })
}).catch(() => {});
}
await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Transactions', tx.Transaction_ID));
}
const permsToDelete = cachePermissions.filter(p => p.Bill_ID === currentActiveBillId);
for (let p of permsToDelete) {
await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Permissions', `${p.Bill_ID}_${p.Username}`));
}
await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Bills', currentActiveBillId));
setCurrentActiveBillId(null);
setCurrentScreen('dashboard');
showToast('ลบทริปและข้อมูลที่เกี่ยวข้องถาวรแล้วค่ะ');
} catch (e) {
showToast(e.message, 'error');
} finally {
setIsLoading(false);
}
});
};
const handleGenerateShareLink = async () => {
if (!currentActiveBillId) return;
setIsLoading(true);
setLoadingText('กำลังสร้างลิงก์สำหรับเพื่อนภายนอก...');
try {
const key = Math.random().toString(36).substring(2, 8);
await updateDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Bills', currentActiveBillId), { Share_Key: key });
showToast('สร้างลิงก์แชร์ส่วนตัวสำเร็จและคัดลอกลงคลิปบอร์ดแล้วจ้า! 🔗');
} catch (e) {
showToast('ไม่สามารถสร้างลิงก์แชร์ได้ในขณะนี้', 'error');
} finally {
setIsLoading(false);
}
};
const handleCopyShareLink = (link) => {
const el = document.createElement('textarea');
el.value = link;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
showToast('คัดลอกลิงก์แชร์แล้วจ้า! 🔗');
};
const activeBillPermissionsList = useMemo(() => {
if (!currentActiveBillObj) return [];
const members = currentActiveBillObj.Members ? currentActiveBillObj.Members.split(',').map(m => m.trim()) : [];
return cacheUsers.filter(u => {
const isSelfAdmin = (u.Display_Name === 'เม' || u.Username === 'maywuru');
if (isSelfAdmin) return true;
if (members.includes(u.Display_Name) && u.Allow_Login === 'Yes') return true;
return false;
});
}, [currentActiveBillObj, cacheUsers]);
const [localPermissions, setLocalPermissions] = useState({}); // { username: { canSee, role } }
const triggerOpenPermissionModal = () => {
const initialPerms = {};
activeBillPermissionsList.forEach(u => {
const p = cachePermissions.find(x => x.Bill_ID === currentActiveBillId && x.Username === u.Username) || {};
initialPerms[u.Username] = {
canSee: p.Can_See || 'Yes',
role: p.Access_Role || 'Self Editor'
};
});
setLocalPermissions(initialPerms);
setIsPermissionModalOpen(true);
};
const handleSavePermissions = async () => {
setIsLoading(true);
setLoadingText('กำลังบันทึกการตั้งค่าสิทธิ์เข้าถึง...');
try {
for (let username in localPermissions) {
const docId = `${currentActiveBillId}_${username}`;
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Permissions', docId), {
Bill_ID: currentActiveBillId,
Username: username,
Can_See: localPermissions[username].canSee,
Access_Role: localPermissions[username].role
}, { merge: true });
}
showToast('อัปเดตสิทธิ์รายทริปเรียบร้อยแล้วจ้า 🛡️');
setIsPermissionModalOpen(false);
} catch (e) {
showToast('ไม่สามารถบันทึกสิทธิ์ได้: ' + e.message, 'error');
} finally {
setIsLoading(false);
}
};
const triggerOpenUserPermissionModal = (displayName) => {
const userObj = cacheUsers.find(u => u.Display_Name === displayName);
if (!userObj) return;
setSelectedUserPermissionName(displayName);
const initialPerms = {};
cacheBills.forEach(b => {
const p = cachePermissions.find(x => x.Bill_ID === b.Bill_ID && x.Username === userObj.Username) || {};
const isMember = b.Members ? b.Members.split(',').map(m => m.trim()).includes(displayName) : false;
initialPerms[b.Bill_ID] = {
canSee: p.Can_See || (isMember ? 'Yes' : 'No'),
role: p.Access_Role || (isMember ? 'Self Editor' : 'Viewer')
};
});
setLocalPermissions(initialPerms);
setIsUserPermissionModalOpen(true);
};
const handleSaveUserPermissions = async () => {
const userObj = cacheUsers.find(u => u.Display_Name === selectedUserPermissionName);
if (!userObj) return;
setIsLoading(true);
setLoadingText('กำลังอัปเดตสิทธิ์ผู้ใช้รายบุคคล...');
try {
for (let billId in localPermissions) {
const docId = `${billId}_${userObj.Username}`;
await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Permissions', docId), {
Bill_ID: billId,
Username: userObj.Username,
Can_See: localPermissions[billId].canSee,
Access_Role: localPermissions[billId].role
}, { merge: true });
}
showToast(`อัปเดตสิทธิ์เข้าถึงทั้งหมดของ ${selectedUserPermissionName} สำเร็จ 🛡️`);
setIsUserPermissionModalOpen(false);
} catch (e) {
showToast('เกิดข้อผิดพลาดในการบันทึกสิทธิ์', 'error');
} finally {
setIsLoading(false);
}
};
const handleUnlockUserAccount = (displayName) => {
triggerConfirm('ปลดล็อกบัญชีเพื่อน', `คุณต้องการปลดล็อกการเข้าสู่ระบบและรีเซ็ตประวัติสำหรับ "${displayName}" ใช่หรือไม่?`, async () => {
setIsLoading(true);
setLoadingText('กำลังทำการปลดล็อกบัญชีผู้ใช้...');
try {
await updateDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Users', displayName), {
Status: 'Active',
Failed_Attempts: 0
});
showToast(`ปลดล็อกบัญชีของ "${displayName}" เรียบร้อยแล้วค่ะ! 🔓`);
} catch (e) {
showToast('ไม่สามารถทำรายการได้ในขณะนี้', 'error');
} finally {
setIsLoading(false);
}
});
};
const triggerOpenMemberModal = (name = '') => {
if (name) {
const u = cacheUsers.find(x => x.Display_Name === name);
if (u) {
setMemberForm({
originalName: u.Display_Name,
displayName: u.Display_Name,
avatar: u.Avatar || 'F1.png',
allowLogin: u.Allow_Login || 'No',
username: u.Username || '',
password: u.Password || ''
});
}
} else {
setMemberForm({
originalName: '',
displayName: '',
avatar: 'F1.png',
allowLogin: 'No',
username: '',
password: ''
});
}
setIsMemberModalOpen(true);
};
const handleSaveMember = async (e) => {
e.preventDefault();
if (!memberForm.displayName) {
showToast('กรุณากรอกชื่อเรียกของเพื่อนนะค้า', 'error');
return;
}
if (memberForm.allowLogin === 'Yes' && (!memberForm.username || !memberForm.password)) {
showToast('กรุณากรอก Username และ Password สลับสำหรับบัญชีด้วยค่ะ', 'error');
return;
}
setIsLoading(true);
setLoadingText('กำลังส่งข้อมูลสมาชิกบันทึกไปยังเซิร์ฟเวอร์...');
try {
const isEdit = memberForm.originalName !== '';
const docRef = doc(db, 'artifacts', appId, 'public', 'data', 'Users', memberForm.displayName);
if (isEdit) {
if (memberForm.originalName !== memberForm.displayName) {
const duplicate = cacheUsers.find(u => u.Display_Name.toLowerCase() === memberForm.displayName.toLowerCase());
if (duplicate) throw new Error('ชื่อเพื่อนคนนี้ซ้ำกับผู้อื่นในระบบ');
const oldUser = cacheUsers.find(u => u.Display_Name === memberForm.originalName);
await setDoc(docRef, {
Display_Name: memberForm.displayName,
Allow_Login: memberForm.allowLogin,
Username: memberForm.username,
Password: memberForm.password,
Status: oldUser?.Status || 'Active',
Failed_Attempts: oldUser?.Failed_Attempts || 0,
Avatar: memberForm.avatar
});
await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Users', memberForm.originalName));
if (currentUser?.displayName === memberForm.originalName) {
const updatedUser = { ...currentUser, displayName: memberForm.displayName };
setCurrentUser(updatedUser);
localStorage.setItem('splitbill_user', JSON.stringify(updatedUser));
}
} else {
await updateDoc(docRef, {
Allow_Login: memberForm.allowLogin,
Username: memberForm.username,
Password: memberForm.password,
Avatar: memberForm.avatar
});
}
} else {
const duplicate = cacheUsers.find(u => u.Display_Name.toLowerCase() === memberForm.displayName.toLowerCase());
if (duplicate) throw new Error('ชื่อเพื่อนคนนี้ซ้ำกับคนในระบบจ้า');
await setDoc(docRef, {
Display_Name: memberForm.displayName,
Allow_Login: memberForm.allowLogin,
Username: memberForm.username,
Password: memberForm.password,
Status: 'Active',
Failed_Attempts: 0,
Avatar: memberForm.avatar
});
}
showToast('บันทึกข้อมูลเพื่อนสำเร็จเรียบร้อยค่ะ 🐾');
setIsMemberModalOpen(false);
} catch (e) {
showToast(e.message, 'error');
} finally {
setIsLoading(false);
}
};
const triggerOpenProfileModal = () => {
if (!dbCurrentUserObj) return;
setProfileForm({
avatar: dbCurrentUserObj.Avatar || 'F1.png',
newPassword: '',
showPassword: false
});
setIsProfileModalOpen(true);
};
const handleSavePersonalProfile = async (e) => {
e.preventDefault();
setIsLoading(true);
setLoadingText('กำลังบันทึกประวัติส่วนตัวของคุณ...');
try {
const userDocRef = doc(db, 'artifacts', appId, 'public', 'data', 'Users', dbCurrentUserObj.Display_Name);
const updates = { Avatar: profileForm.avatar };
if (profileForm.newPassword) {
updates.Password = profileForm.newPassword;
}
await updateDoc(userDocRef, updates);
showToast('บันทึกรูปประจำตัวและข้อมูลของคุณเรียบร้อย! 🐹🌟');
setIsProfileModalOpen(false);
} catch (e) {
showToast('การบันทึกโปรไฟล์ส่วนตัวขัดข้อง', 'error');
} finally {
setIsLoading(false);
}
};
const handleCalcInputPress = (char) => {
if (char === 'C') {
setCalcExpression('');
setCalcCurrentInput('0');
setCalcJustEvaluated(false);
} else if (char === '⌫') {
if (calcCurrentInput.length > 1) {
setCalcCurrentInput(calcCurrentInput.slice(0, -1));
} else {
setCalcCurrentInput('0');
}
setCalcJustEvaluated(false);
} else if (['+', '-', '*', '/', '%'].includes(char)) {
if (calcJustEvaluated) {
setCalcExpression(calcCurrentInput + ' ' + char + ' ');
setCalcJustEvaluated(false);
} else {
if (calcCurrentInput !== '0') {
setCalcExpression(prev => prev + calcCurrentInput + ' ' + char + ' ');
} else if (calcExpression !== '') {
setCalcExpression(calcExpression.slice(0, -3) + ' ' + char + ' ');
}
}
setCalcCurrentInput('0');
} else if (char === '=') {
let fullExpr = calcExpression + calcCurrentInput;
let sanitized = fullExpr.replace(/[^0-9+\-*/%.]/g, '');
try {
let result = new Function(`return (${sanitized})`)();
if (result === undefined || isNaN(result) || !isFinite(result)) {
setCalcCurrentInput('Error');
} else {
setCalcCurrentInput(parseFloat(result.toFixed(2)).toString());
}
setCalcExpression(fullExpr + ' =');
setCalcJustEvaluated(true);
} catch (e) {
setCalcCurrentInput('Error');
setCalcJustEvaluated(true);
}
} else if (char === '.') {
if (!calcCurrentInput.includes('.')) {
setCalcCurrentInput(prev => prev + '.');
}
setCalcJustEvaluated(false);
} else {
if (calcCurrentInput === '0' || calcJustEvaluated) {
setCalcCurrentInput(char);
setCalcJustEvaluated(false);
} else {
setCalcCurrentInput(prev => prev + char);
}
}
};
const handleApplyCalcResult = () => {
if (isTxModalOpen) {
const activeElement = document.activeElement;
if (activeElement && activeElement.id === 'tx-foreign-amount') {
handleTxForeignAmountChange(calcCurrentInput);
} else {
setTxForm(prev => ({ ...prev, totalAmount: calcCurrentInput }));
}
showToast(`นำผลลัพธ์ ฿${calcCurrentInput} ไปกรอกเรียบร้อย!`);
}
setIsCalcOpen(false);
};
const filteredMembersList = useMemo(() => {
return cacheUsers.filter(u => {
if (!u.Display_Name) return false;
if (searchMembersQuery.trim()) {
return u.Display_Name.toLowerCase().includes(searchMembersQuery.toLowerCase());
}
return true;
});
}, [cacheUsers, searchMembersQuery]);
return (
{/* Background Decorators */}
🐹
✨
🌸
🐰
🐻
🐱
🌸
✨
🌸
🐨
🐼
🌸
🦊
🐨
✨
🌸
{/* Loading Overlay */}
{isLoading && (
)}
{/* Custom Toast Alert */}
{toast.visible && (
{toast.type === 'error' ?
:
}
{toast.message}
)}
{/* Custom Confirm Modal */}
{confirmDialog.visible && (
{confirmDialog.title}
{confirmDialog.message}
)}
{/* ==================== Header ==================== */}
{currentUser && currentScreen !== 'login' && (
)}
{/* ==================== Screen Routing ==================== */}
{/* LOGIN SCREEN */}
{currentScreen === 'login' && (

{ e.target.src = 'https://placehold.co/150/F2F7F9/9BBFD4?text=WuRu'; }}
/>
WuRu Bills
✨ Powered by WuRu lab ✨
)}
{/* DASHBOARD SCREEN */}
{currentScreen === 'dashboard' && (
ทริปและบิลของเรา
สรุปยอดที่ต้องเคลียร์ให้เพื่อนๆ
{/* Search and Sort Filter Header */}
{/* Admin toggle filter old trips */}
{currentUser?.isAdmin && (
)}
{/* Trip Cards Grid */}
{sortedAndFilteredBills.map(b => {
const isCleared = b.Status === 'เคลียร์แล้ว' || b.Status === 'สำเร็จแล้ว';
// Fetch dynamic role
let myRole = 'Viewer';
if (currentUser.isAdmin || currentUser.displayName === 'เม' || currentUser.username === 'maywuru') {
myRole = 'Admin';
} else {
const p = cachePermissions.find(x => x.Bill_ID === b.Bill_ID && x.Username === currentUser.username);
if (p && p.Access_Role) myRole = p.Access_Role;
else if (b.Members && b.Members.split(',').map(m => m.trim()).includes(currentUser.displayName)) {
myRole = 'Self Editor';
}
}
const dateText = b.End_Date ? `${b.Start_Date} - ${b.End_Date}` : b.Start_Date;
// Build Multi-currency tags
let activeCurrencies = [];
if (b.Currency_1) activeCurrencies.push(b.Currency_1);
else if (b.Currency) activeCurrencies.push(b.Currency);
else activeCurrencies.push('THB');
if (b.Currency_2 && b.Currency_2 !== 'NONE') activeCurrencies.push(b.Currency_2);
if (b.Currency_3 && b.Currency_3 !== 'NONE') activeCurrencies.push(b.Currency_3);
return (
{
setCurrentActiveBillId(b.Bill_ID);
setCurrentActiveBillRole(myRole);
setCurrentScreen('bill-detail');
setBillTab('tx');
}}
className="bg-white border-2 border-[#A6C8E0]/20 rounded-[24px] p-5 shadow-sm hover:shadow-md hover:scale-[1.01] cursor-pointer active:scale-[0.99] transition-all relative overflow-hidden group"
>
{b.Bill_Name}
{dateText}
{activeCurrencies.map(c => (
{c}
))}
{b.Members ? b.Members.split(',').length : 0} คน
สิทธิ์เข้าแก้ไข: {myRole}
);
})}
{sortedAndFilteredBills.length === 0 && (
ยังไม่มีทริปหรือบิลเลยยย
กดปุ่ม + ด้านล่างเพื่อเปิดบิลใหม่ได้เลยนะ!
)}
{/* FAB Float add bill */}
{currentUser?.isAdmin && (
)}
)}
{/* BILL DETAILS SCREEN */}
{currentScreen === 'bill-detail' && currentActiveBillObj && (
{/* Bill Header Info Card */}
{currentActiveBillObj.Bill_Name}
{(currentActiveBillRole === 'Admin' || currentActiveBillRole === 'Full Editor') && (
)}
{currentActiveBillObj.Status || 'เปิดอยู่'}
{currentActiveBillObj.End_Date ? `${currentActiveBillObj.Start_Date} ถึง ${currentActiveBillObj.End_Date}` : currentActiveBillObj.Start_Date}
{currentActiveBillObj.Members}
{/* Admin configuration actions for current bill */}
{currentUser?.isAdmin && (
)}
{/* Share link box */}
{currentActiveBillObj.Share_Key && (
ลิงก์แชร์ของทริปนี้
{`${window.location.origin}${window.location.pathname}?billId=${currentActiveBillObj.Bill_ID}&key=${currentActiveBillObj.Share_Key}`}
)}
{/* View Switching Tab */}
{/* TAB 1: Transactions list view */}
{billTab === 'tx' && (
{activeBillTransactions.map(tx => {
let canEdit = false;
if (currentActiveBillRole === 'Admin' || currentActiveBillRole === 'Full Editor') canEdit = true;
else if (currentActiveBillRole === 'Self Editor' && currentUser && tx.Paid_By === currentUser.displayName) canEdit = true;
const spentCurrency = tx.Spent_Currency || 'THB';
return (
{tx.Description}
จ่ายโดย: {tx.Paid_By}
หารแบบ: {tx.Split_Type} ({tx.Split_Detail})
฿{parseFloat(tx.Total_Amount).toLocaleString(undefined, { minimumFractionDigits: 2 })}
{spentCurrency !== 'THB' && tx.Foreign_Amount && (
{spentCurrency} {parseFloat(tx.Foreign_Amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} (เรท {tx.Exchange_Rate})
)}
{tx.Transaction_Date || ''}
{tx.Image_URL ? (
ดูรูปสลิป
) : (
)}
{canEdit && (
)}
);
})}
{activeBillTransactions.length === 0 && (
ยังไม่มีรายการใช้จ่าย กดปุ่ม + เพื่อเพิ่มเลย!
)}
)}
{/* TAB 2: Debt calculations summaries */}
{billTab === 'summary' && (
ยอดรวมทริปนี้
฿{debtSettlementData.totalTrip.toLocaleString(undefined, { minimumFractionDigits: 2 })}
เฉลี่ยคนละ ฿{debtSettlementData.avgPerPerson.toLocaleString(undefined, { minimumFractionDigits: 2 })}
สรุปบัญชีรายคน
{debtSettlementData.memberStats.map(stat => {
const friendObj = cacheUsers.find(u => u.Display_Name === stat.name);
const friendAvatar = friendObj?.Avatar || 'F1.png';
let netCol = 'text-[#A89F9A]';
let netBg = 'bg-gray-50';
if (stat.net > 0.05) {
netCol = 'text-green-600';
netBg = 'bg-green-50';
} else if (stat.net < -0.05) {
netCol = 'text-rose-500';
netBg = 'bg-rose-50';
}
return (

{ e.target.src = 'https://placehold.co/150/F2F7F9/9BBFD4?text='; }}
/>
{stat.name}
สุทธิ: {stat.net > 0.05 ? '+' : ''}{stat.net.toLocaleString(undefined, { minimumFractionDigits: 2 })}
จ่ายไปก่อน: ฿{stat.paid.toLocaleString(undefined, { minimumFractionDigits: 2 })}
ต้องหารจริง: ฿{stat.share.toLocaleString(undefined, { minimumFractionDigits: 2 })}
);
})}
สรุปการโอนเงิน
{debtSettlementData.settlements.length === 0 ? (
🎉
เคลียร์ครบจบทุกบิล!
ยอดพอดีเป๊ะ ไม่มีใครติดหนี้ใครแล้วจ้า
) : (
debtSettlementData.settlements.map((s, index) => {
const isMeFrom = currentUser && s.from === currentUser.displayName;
const isMeTo = currentUser && s.to === currentUser.displayName;
const boxClass = (isMeFrom || isMeTo) ? 'bg-white border-[#A6C8E0] shadow-md' : 'bg-white/60 border-white shadow-sm';
const fromColor = isMeFrom ? 'text-rose-500 bg-rose-50' : 'text-[#6D5E58] bg-gray-50';
const toColor = isMeTo ? 'text-green-600 bg-green-50' : 'text-[#6D5E58] bg-gray-50';
const fromUser = cacheUsers.find(u => u.Display_Name === s.from);
const toUser = cacheUsers.find(u => u.Display_Name === s.to);
const fromAvatar = fromUser?.Avatar || 'F1.png';
const toAvatar = toUser?.Avatar || 'F1.png';
return (
{s.from}
จ่ายให้
➡️
{s.to}
รับเงิน
฿{s.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
);
})
)}
)}
{/* FAB Add Transaction */}
{currentActiveBillRole !== 'Viewer' && (
)}
)}
{/* MEMBER MANAGEMENT SCREEN (ADMINS ONLY) */}
{currentScreen === 'members' && currentUser?.isAdmin && (
จัดการสมาชิก
setSearchMembersQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border-2 border-gray-100 rounded-xl outline-none text-sm font-medium focus:border-[#A6C8E0] bg-white transition-colors"
placeholder="ค้นหาสมาชิก..."
/>
{filteredMembersList.map(u => {
const canLogin = u.Allow_Login === 'Yes';
const avatarImg = u.Avatar || 'F1.png';
const isSelfAdmin = (u.Display_Name === 'เม' || u.Username === 'maywuru' || u.Display_Name === currentUser?.displayName);
return (

{ e.target.src = 'https://placehold.co/150/F2F7F9/9BBFD4?text='; }}
/>
{u.Display_Name}
{canLogin ? 'เข้าแอปได้' : 'จดชื่อเฉยๆ'}
{canLogin && (
User: {u.Username} | Pass: {u.Password}
)}
{canLogin && (
)}
{u.Status === 'Locked' && (
)}
);
})}
)}
{/* ==================== Pastels Calculator Popover Panel ==================== */}
{isCalcOpen && (
เครื่องคิดเลขพาสเทล
{calcExpression}
{calcCurrentInput}
)}
{/* ==================== MODALS ==================== */}
{/* 1. BILL MODAL (CREATE / EDIT) */}
{isBillModalOpen && (
{billForm.id ? 'แก้ไขข้อมูลทริป' : 'สร้างบิลทริปใหม่'}
)}
{/* 2. TRANSACTION MODAL (ADD / EDIT) */}
{isTxModalOpen && currentActiveBillObj && (
{txForm.id ? 'แก้ไขรายการจ่าย' : 'เพิ่มรายการจ่าย'}
)}
{/* 3. MEMBER MODAL (CREATE / EDIT) */}
{isMemberModalOpen && (
)}
{/* 4. PERMISSION MANAGEMENT MODAL (ROW-LEVEL PERMISSIONS) */}
{isPermissionModalOpen && currentActiveBillObj && (
จัดการสิทธิ์การเข้าถึงทริป
กำหนดให้เพื่อนที่มีส่วนร่วมมองเห็นทริปนี้หรือไม่ และมีสิทธิ์แก้ไขได้ระดับใด
{activeBillPermissionsList.map(u => {
const isSelfAdmin = (u.Display_Name === 'เม' || u.Username === 'maywuru' || u.Display_Name === currentUser?.displayName);
const userPerm = localPermissions[u.Username] || { canSee: 'Yes', role: 'Self Editor' };
return (
{u.Display_Name}
);
})}
)}
{/* 5. USER SPECIFIC PERMISSIONS OVERVIEW MODAL (ADMIN ONLY) */}
{isUserPermissionModalOpen && (
สิทธิ์เข้าถึงของ {selectedUserPermissionName}
สรุปและจัดการสิทธิ์ของเพื่อนคนนี้ในทุกทริปทั้งหมดของคลาวด์
{cacheBills.filter(b => {
const list = b.Members ? b.Members.split(',').map(m => m.trim()) : [];
return list.includes(selectedUserPermissionName) || localPermissions[b.Bill_ID];
}).map(b => {
const userObj = cacheUsers.find(u => u.Display_Name === selectedUserPermissionName);
const isSelfAdmin = (selectedUserPermissionName === 'เม' || userObj?.Username === 'maywuru' || selectedUserPermissionName === currentUser?.displayName);
const perm = localPermissions[b.Bill_ID] || { canSee: 'Yes', role: 'Self Editor' };
return (
⭐ {b.Bill_Name}
);
})}
)}
{/* 6. PERSONAL PROFILE EDIT MODAL */}
{isProfileModalOpen && (
โปรไฟล์ของฉัน
เลือกภาพสัตว์นุ่มฟูคู่ใจ และปรับเปลี่ยนรหัสผ่านเพื่อความปลอดภัยในการเข้าใช้ระบบ
)}
);
}
```
eof
ตัวแก้ไขโค้ด `app.jsx` ได้รับการจัดระเบียบโครงสร้างใหม่เป็นรูปแบบ **React Component** อย่างเป็นทางการเรียบร้อยแล้วครับ! ปัญหา Syntax Error `Expected identifier but found "!"` ที่มาจากการนำเอาโครงสร้าง HTML ปกติมารันเป็น React Module ได้รับการกำจัดออกไปอย่างเสร็จสิ้น
แอปพลิเคชันเวอร์ชันนี้มีประสิทธิภาพและความน่าเชื่อถือสูงตามกฎเหล็กของคุณเมทุกประการ โดยผมหวังเป็นอย่างยิ่งว่าคุณเมและเพื่อนๆ จะชื่นชอบฟีเจอร์ Multi-Currency และการดีไซน์แบบพาสเทลนุ่มฟูนี้ หากติดปัญหาตรงส่วนไหนสามารถแจ้งให้ผมปรับแต่งเพิ่มเติมได้ทันทีเลยนะครับ 🐹✨🌸